Skip to content

feat(motion): adopt spring tokens and add shared CSS-transition helper - #5112

Open
rjmreis wants to merge 5 commits into
callstack:mainfrom
rjmreis:css-transitions
Open

rjmreis wants to merge 5 commits into
callstack:mainfrom
rjmreis:css-transitions

Conversation

@rjmreis

@rjmreis rjmreis commented Sep 8, 2026 •

Copy link
Copy Markdown

Motivation

MD3 Expressive replaces easing+duration with springs where motion is spring-shaped; CSS transitions are the default otherwise. #5015 handled the library swap + CSS-transition conversion; this PR adds spring adoption + a shared getTransition helper.

  • getTransition() CSS-transition helper in theme/tokens/sys/motion.ts
  • Badge: uses helper, fixes duration/easing/reduce-motion
  • Checkbox: deduped onto helper, no behavior change
  • TextInput: label float/active-border now CSS transitions
  • SegmentedButtonItem: checkmark spring now fast.spatial token, matching Switch/FAB

Follow-up (separate issue): some #5015 files still use imperative withTiming for non-spring motion, out of scope here.

Related issue

Part of the MD3 motion modernization; no tracking issue yet.

Test plan

yarn typecheck/lint/jest pass (686 tests), incl. new getTransition + TextInput focus/blur tests. Manual verification + screenshots below.

Screenshots

Badge
Simulator.Screen.Recording.-.iPhone.17.Pro.-.2026-09-08.at.13.08.16.mov
Checkbox / Checkbox Item
Simulator.Screen.Recording.-.iPhone.17.Pro.-.2026-09-08.at.13.10.19.mov
TextInput
Simulator.Screen.Recording.-.iPhone.17.Pro.-.2026-09-08.at.12.56.43.mov
Segmented Buttons
Simulator.Screen.Recording.-.iPhone.17.Pro.-.2026-09-08.at.12.58.39.mov

Adds getTransition() helper, fixes Badge's reduce-motion/duration/easing, converts TextInput label float to CSS transitions, and moves SegmentedButtonItem's checkmark spring onto theme tokens.
Copilot AI balanced review requested due to automatic review settings September 8, 2026 11:02

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

It changes core animation mechanics (tokenized CSS transitions + spring adoption) across widely used components like TextInput, which is cross-platform sensitive and should get final human verification.

Pull request overview

This PR continues the MD3 Expressive motion modernization by (1) introducing a shared helper for Reanimated CSS-transition styles derived from theme motion tokens, and (2) migrating/deduping several components to use those tokens and the CSS-transition tier consistently (including adopting a spring token for SegmentedButtonItem).

Changes:

  • Added getTransition(...) in src/theme/tokens/sys/motion.ts and unit tests validating token lookup + reduce-motion behavior.
  • Refactored TextInput label/indicator animations from imperative Reanimated timing/shared-values to render-driven CSS transitions, and added regression tests asserting the computed label styles across focus/blur.
  • Updated Badge and Checkbox to use the shared transition helper; updated SegmentedButtonItem to use a theme spring token via toRawSpring(...).
File summaries
File Description
src/theme/tokens/sys/motion.ts Adds getTransition helper to standardize CSS transition duration/easing/property from motion tokens.
src/theme/tests/motion.test.ts Adds unit coverage for getTransition token lookup, reduce-motion, and easing construction.
src/components/TextInput/TextInput.tsx Removes now-unused animation handler types; consumes updated hook-driven animation state.
src/components/TextInput/hooks.ts Replaces imperative label/indicator animation with boolean-state-driven CSS transition styles; wires reduce-motion via context.
src/components/TextInput/constants.ts Removes timing/easing constants tied to imperative withTiming animation.
src/components/SegmentedButtons/SegmentedButtonItem.tsx Replaces hardcoded spring config with theme spring token via toRawSpring.
src/components/Checkbox/Checkbox.tsx Dedupes CSS transition props through getTransition.
src/components/Badge.tsx Switches Badge opacity transition to tokenized getTransition + useReduceMotion.
src/components/tests/TextInput.test.tsx Adds assertions for label wrapper/text styles across focus/blur in filled + outlined variants.
Review details
  • Files reviewed: 9/9 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/components/TextInput/hooks.ts Outdated
Copilot AI review requested due to automatic review settings September 8, 2026 11:13

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

It changes animation mechanisms and motion-token wiring in core components (notably TextInput), where subtle platform/runtime differences are hard to fully validate from diff review alone.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

src/components/tests/TextInput.test.tsx:83

  • getLabelWrapper currently returns the first node with aria-hidden === true, but TextInput can render multiple aria-hidden elements (e.g. the error icon wrapper). That makes this helper non-specific and can cause future tests to assert styles on the wrong element if another aria-hidden node appears earlier in the tree.

Consider narrowing the predicate to only match the label wrapper by additionally requiring a transition style marker (e.g. transitionProperty in the style object/array), which should be unique to the animated label wrapper in these tests.

  • Files reviewed: 9/9 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

Comment thread src/components/Badge.tsx
transitionDuration: 150 * scale,
transitionProperty: 'opacity',
...getTransition(theme, 'opacity', 'short3', 'standard', reduceMotion),
};

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have you considered typing this style as CSSStyle<ViewStyle> like in Checkbox.tsx?

@rjmreis rjmreis Sep 15, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call, done! Badge renders Animated.Text, so I've updated it to CSSStyle<TextStyle>.

Copilot AI review requested due to automatic review settings September 15, 2026 14:02

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The helper’s default generic currently permits invalid transition-property names.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

src/theme/tokens/sys/motion.ts:142

  • The Record<string, unknown> default erases transitionProperty validation because its keys accept any string, so typos such as 'opactiy' compile and produce a no-op/invalid transition at runtime. Use Reanimated's own default CSSTransitionProperties style type here so callers are constrained to supported style keys.
  • Files reviewed: 9/9 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

@rjmreis
rjmreis requested a review from JKobrynski September 23, 2026 07:06

@JKobrynski JKobrynski left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just one thing from me


const checkSpringConfig = React.useMemo(
() => ({
...toRawSpring(theme.motion.spring.slow.spatial),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the checkmark spring be fast.spatial, like Switch and FAB? M3 maps fast to small components, slow to full-screen.

- SegmentedButtonItem checkmark spring now uses fast.spatial (small component) instead of slow.spatial, matching Switch/FAB precedent
- getTransition's generic no longer erases transitionProperty validation; defaults to a real style union and Badge/Checkbox now specify their own concrete style type
- TextInput.test.tsx's getLabelWrapper now also requires a transitionProperty marker (via getAnimatedStyle) so it can't match an unrelated aria-hidden node
Copilot AI balanced review requested due to automatic review settings October 5, 2026 15:02

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

SegmentedButtonItem uses the fast spring token instead of the documented slow token, substantially changing its motion.

Review effort: Balanced
Findings: 1 Medium severity

Open (1)

Comment thread src/components/SegmentedButtons/SegmentedButtonItem.tsx
@github-actions

github-actions Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Found potential problems with the pull request:

  • The description is too long. Please keep it under 1000 characters.

@satya164 satya164 left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review by Claude

See the inline comments. (via Claude, verify if it makes sense)

Comment on lines +140 to +149
theme: { motion: MotionConfig },
property: NonNullable<CSSTransitionProperties<S>['transitionProperty']>,
durationToken: keyof MotionDuration,
easingToken: keyof MotionEasing,
reduceMotion: boolean
): Required<Pick<CSSTransitionProperties<S>, 'transitionProperty'>> &
CSSTransitionProperties<S> {
return {
transitionProperty: property,
transitionDuration: reduceMotion ? 0 : theme.motion.duration[durationToken],

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getTransition ignores theme.animation.scale. On main, Badge used 150 * scale, so this is a regression for Badge. A theme with animation: { scale: 0 } (often used to turn off animations in tests and screenshots) still fades the Badge for 150 ms now, and scale: 2 no longer slows it down. Surface, Chip and Button still multiply their durations by theme.animation.scale, so helper users now behave differently from those components.

Multiply the token by the scale and keep the reduceMotion check (a component-level theme prop can override the scale that PaperProvider sets for reduced motion):

Suggested change
theme: { motion: MotionConfig },
property: NonNullable<CSSTransitionProperties<S>['transitionProperty']>,
durationToken: keyof MotionDuration,
easingToken: keyof MotionEasing,
reduceMotion: boolean
): Required<Pick<CSSTransitionProperties<S>, 'transitionProperty'>> &
CSSTransitionProperties<S> {
return {
transitionProperty: property,
transitionDuration: reduceMotion ? 0 : theme.motion.duration[durationToken],
theme: { motion: MotionConfig; animation: { scale: number } },
property: NonNullable<CSSTransitionProperties<S>['transitionProperty']>,
durationToken: keyof MotionDuration,
easingToken: keyof MotionEasing,
reduceMotion: boolean
): Required<Pick<CSSTransitionProperties<S>, 'transitionProperty'>> &
CSSTransitionProperties<S> {
return {
transitionProperty: property,
transitionDuration: reduceMotion
? 0
: theme.motion.duration[durationToken] * theme.animation.scale,

motion.test.ts then needs animation: { scale: 1 } in its theme, plus a test that a scale of 0 gives a duration of 0. (via Claude, verify if it makes sense)

Comment on lines +125 to +127
* Zeroes the duration when `reduceMotion` is set, per the CSS tier's
* reduce-motion contract (a Reanimated `ReduceMotion` option doesn't apply
* to CSS transitions).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The repo does not define a "CSS tier" or a "reduce-motion contract". The same goes for "the motion decision rule" in src/components/TextInput/hooks.ts line 32. A reader cannot look these up. The documented rule is the useReduceMotion JSDoc in src/theme/accessibility/ReduceMotionContext.tsx, and it says to keep opacity and color animations when reduced motion is on. This helper zeroes every property, opacity included (Badge, the Checkbox fill, the TextInput input fade). Either follow that rule or update the useReduceMotion doc. Then describe the behavior in plain words here and in hooks.ts, for example "Sets the duration to 0 when reduceMotion is true, because Reanimated's ReduceMotion option has no effect on CSS transitions." (via Claude, verify if it makes sense)

reduceMotion: reduceMotion ? ReduceMotion.Always : ReduceMotion.Never,
});
}, [checked, checkScale, reduceMotion, showSelectedCheck]);
checkScale.value = withSpring(checked ? 1 : 0, checkSpringConfig);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The check animation runs on a shared value with withSpring, but it is driven by the checked prop and not by a gesture or scroll. The project rule is to use Reanimated CSS transitions (transitionProperty and transitionDuration, as in Badge.tsx and Checkbox.tsx) for state changes, and to keep shared values for gesture or scroll driven motion. Please move the check scale and the icon scale to CSS transitions on transform. Reanimated 4.3 exports linear(...), so you can keep the fast.spatial spring shape by sampling it into linear() points for transitionTimingFunction. With that change, the useMemo and the effect go away. (via Claude, verify if it makes sense)

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants